昨天我們利用 RRF 演算法成功將關鍵字(Lexical)與向量(Semantic)融合為 Hybrid Search。
但如果你直接把 Hybrid Search 的 Top 5 丟給 LLM 或呈現在 CLI,會發現一個尷尬的問題:
當使用者搜尋 build_index 時,清單裡可能同時出現:
src/build_index.py 的整檔 file chunk(包含一堆頂層說明)。src/rag_chat.py:4 的單行 import build_index。src/build_index.py:42 真正的 def build_index(): 函式本體。如果隨機把這三個丟給模型,LLM 很容易抓著頂層說明或 import 行講半天,反而漏掉了真正實作邏輯的核心函式。
在第三週引入昂貴的 LLM Reranker 前,今天我們要先加上一層零成本、零延遲的 Deterministic Reranking(確定性規則重排),讓「真正定義」與「精確名稱」無條件排在前面,並為前兩週的成果進行一次完整盤點!
模型不是萬能的,簡單的規則往往比神經網路更可靠:
定義優先(Definition Over Reference):在程式碼理解情境中,使用者問一個名稱,90% 是想看它的「實作定義(class / function)」,而不是看別的地方怎麼 import 它。
同分穩定性(Tie-Breaking):當兩筆結果分數相同時,若沒有穩定的第二排序依據,每次重跑順序都會亂跳,導致測試無法驗證、使用者無法重現結果。
我們為候選集設計簡單透明的階梯式加權:
function、class 或 method 者,優先於泛泛的 file 或單行 import($+0.3$)。app/reranker.py在 app/reranker.py 中實作輕量重排邏輯:
# app/reranker.py
from typing import List, Dict, Any
class DeterministicReranker:
DEFINITION_KINDS = {"function", "class", "method"}
@classmethod
def rerank(cls, query: str, candidates: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]:
"""
對候選 Evidence 進行規則加權與穩定排序
"""
query_clean = query.strip().lower()
scored_candidates = []
for item in candidates:
# 複製資料避免汙染原始候選
entry = dict(item)
base_score = entry.get("rrf_score", 0.0)
bonus = 0.0
name = str(entry.get("name", "")).lower()
kind = entry.get("kind", "")
# 1. 精確名稱命中加分
if name == query_clean:
bonus += 0.5
# 2. 定義區塊優先於單純引用或整檔
if kind in cls.DEFINITION_KINDS:
bonus += 0.3
final_score = base_score + bonus
entry["final_score"] = final_score
scored_candidates.append(entry)
# 3. 穩定排序:先比 final_score(降冪),同分比路徑(升冪),再比行號(升冪)
scored_candidates.sort(
key=lambda x: (-x["final_score"], x["path"], x["start_line"])
)
return scored_candidates[:limit]
tests/unit/test_reranker.py驗證定義節點是否能成功逆轉 RRF 分數稍微落後的情況,並確保同分時順序固定不跳動:
# tests/unit/test_reranker.py
from app.reranker import DeterministicReranker
def test_deterministic_rerank_priority():
query = "build_index"
# 模擬 Hybrid Search 出來的候選:import 行的 RRF 分數略高於函式定義
candidates = [
{
"path": "src/rag_chat.py",
"start_line": 4,
"end_line": 4,
"kind": "import",
"name": "build_index",
"rrf_score": 0.032
},
{
"path": "src/build_index.py",
"start_line": 42,
"end_line": 80,
"kind": "function",
"name": "build_index",
"rrf_score": 0.030
},
{
"path": "src/build_index.py",
"start_line": 1,
"end_line": 100,
"kind": "file",
"name": "build_index",
"rrf_score": 0.028
}
]
reranked = DeterministicReranker.rerank(query, candidates, limit=3)
# 斷言:函式定義必須被推到第 1 名
assert reranked[0]["kind"] == "function"
assert reranked[0]["path"] == "src/build_index.py"
assert reranked[0]["start_line"] == 42
目前專案累積的所有確定性單元測試(包含邊界安全、AST 解析、SQLite 索引、直接 import 追蹤、向量打包與 Rerank):
uv run python -m pytest tests/unit -q
終端機驗收輸出:
........ [100%]
8 passed in 0.12s
8 個單元測試全數通過,且完全不需要開網路或依賴本機模型運作。
花兩週打下的地基,讓系統具備了完整的 Codebase 靜態分析與檢索管線:
| 模組 | 產出成果與物理意義 | 狀態 |
|---|---|---|
| AST 結構化切分 | 取得 23 個不被截斷的語法 Chunks(7 檔案 + 16 類別/函式) |
| PASS |
| 持久化 Symbol 庫 | 23 個 Symbol 寫入 SQLite,支援 Qualified Name 與同名衝突保留
| PASS |
| 直接依賴關係圖 | 28 條 Import 記錄,可逆向追蹤模組被誰引用
| PASS |
| 本機 Code Vector | 整合 Ollama bge-m3:latest,產出 1024 維向量並支援餘弦檢索
| PASS |
| 混合檢索 (Hybrid) | RRF 無痛融合關鍵字精確度與向量語意
| PASS |
| 確定性重排 (Rerank) | 定義與精確名稱優先,排序 100% 可重現
| PASS |
尚未具備跨檔案呼叫鏈:目前只知道「A import B」,還不知道「A 裡面的哪個 function 實際呼叫了 B 裡面的哪個 method」。
檢索品質尚未量化:目前的排序是直覺規則,還沒有科學化的評估指標來證明 Hybrid 到底比純文字好多少。
下一週,我們暫時放下新功能開發,專注於「評估(Evaluation)」與「本地 LLM Reranker」:
明天,我們將動手寫下第一份 data/eval_cases.json,讓檢索系統不再憑感覺打分數!